Skip to content
lab components / Tables and lists

Bulk action bar

Let users check multiple rows and run one action (edit, tag, export, delete) on all of them at once.

This is a Lab component!

That means it doesn't satisfy our definition of done and may be changed or even deleted. For an exact status, please reach out to the Fancy team through the dev_fancy or ux_fancy channels.

import { BulkActionBar, useBulkActions } from "@siteimprove/fancylab";

How it works

The BulkActionBar is an addition, not a takeover: it appears in the table's own row when a selection starts, offers the actions that apply to that selection, and gets out of the way when the selection ends. It holds no state of its own.

Where it appears

The bar appears in its own row between the Table toolbar and the table's column headers as soon as at least one row is selected. The toolbar (filter and search) stays live above it, and the bar pushes the table down without overlapping.

Dismissing the bar, either with the × button or with Esc once the scope menu is closed, clears the selection and collapses it.

The bar owns no state

The bar is presentational: it owns no state. It renders the scope menu, the "Select all" shortcut, the actions, and the dismiss control, and calls back to your handlers, mirroring how Table leaves row selection to the consumer.

It renders nothing at all while selectedCount is 0, so it appears with the first selected row and disappears with the last without you gating it.

Managing selection with useBulkActions

Selection state lives outside the bar. The shipped useBulkActions hook can manage it for you. It tracks selection as an include/exclude predicate and derives the counts, the page-header tri-state, and the scope handlers. It's built for server-side pagination: you pass the server's totalCount and only ever hand it the current page, so "select all" never enumerates rows you haven't loaded.

Spread bulk.getBarProps(pageItems) into the bar and add your own actions / destructiveAction. To act on the selection, read bulk.selection (send it to your backend) or, client-side, items.filter((i) => bulk.isSelected(i) === true).

You can also skip the hook and pass the props yourself. Either way the bar never owns the state.

Selection in server-side tables

Three things are worth knowing about the hook when the table is paginated server-side.

  • Tri-state selection. isSelected returns true / false / "partial", so a parent row with only some children selected can drive an indeterminate checkbox. Flag it with bulk.setPartial(item, true); the hook stores the flag (what "partial" means is your domain) and never counts a partial item as a whole one. Because "partial" is truthy, always compare with === true.
  • Hydrating a pre-selection. Pre-selected state that arrives from an async fetch after mount can be hydrated with bulk.seed(selection), which takes ids, not items, so it works when the pre-selected rows live on pages you haven't loaded.
  • The count is capped. selectedCount is capped at totalCount, so a seeded selection that a later filter narrows can never report more than the total (it may still overcount rows outside the filter; an exact count needs a server-side intersection).

Examples

Anatomy

The bar starts with a button showing how many items are selected — "4 selected" below — which opens the scope menu. The "Select all" shortcut next to it mirrors the menu's most-used command in one click, and hides once everything is selected. Actions and the destructive action sit in their own divider-separated groups, and the icon-only dismiss button ends the bar.

The scope menu lists only the commands that would change the selection: with the whole page already selected, "Select current page" is not there rather than greyed out. Each command's count is the number of items it would affect — with 21 rows selected out of 33, the menu offers "Deselect all items (21)".

("Select 4 sites" appears in this example only while nothing is selected. It stands in for ticking rows in a real table, so you can bring the bar back after dismissing it.)

4 selected
Theme: agentic-ai-2025
const totalCount = 33; const pageTotal = 10; const [selectedCount, setSelectedCount] = useState(4); const [selectedOnPage, setSelectedOnPage] = useState(4); // Tag runs a short async task, to show an action in its loading state. const [tagging, setTagging] = useState(false); const { requestDelete, modal } = useConfirmDelete(); const clearAll = () => { setSelectedCount(0); setSelectedOnPage(0); }; return ( <> {/* The bar renders nothing at 0 selected, so this example needs a way back. In a real table that's the row checkboxes. */} {selectedCount === 0 && ( <Button onClick={() => { setSelectedCount(4); setSelectedOnPage(4); }} > Select 4 sites </Button> )} <BulkActionBar selectedCount={selectedCount} totalCount={totalCount} selectedOnPageCount={selectedOnPage} pageItemCount={pageTotal} onSelectPage={() => { setSelectedOnPage(pageTotal); setSelectedCount((c) => Math.max(c, pageTotal)); }} onDeselectPage={() => { setSelectedCount((c) => c - selectedOnPage); setSelectedOnPage(0); }} onSelectAll={() => { setSelectedCount(totalCount); setSelectedOnPage(pageTotal); }} onDeselectAll={clearAll} actions={[ { text: "Tag", icon: <IconLabel />, loading: tagging, onClick: () => { setTagging(true); window.setTimeout(() => setTagging(false), 1500); }, }, { text: "Export", icon: <IconDownload />, onClick: () => console.log("Export"), }, ]} destructiveAction={{ text: "Delete", onClick: () => requestDelete(selectedCount, clearAll), }} onDismiss={clearAll} /> {modal} </> );

Grouped sub-actions

Some actions have more than one way to run — Delete can mean delete the item, or delete the item and its data. Give the action subActions and the bar groups them under a chevron instead of adding a second button, labelling that chevron for you. The main button stays the common choice, and the same grouping works on any action with variants — Edit, Add, Share.

4 selected
4 selected
Theme: agentic-ai-2025
const { requestDelete, modal } = useConfirmDelete(); const barProps = { selectedCount: 4, totalCount: 33, selectedOnPageCount: 4, pageItemCount: 12, onSelectPage: () => undefined, onDeselectPage: () => undefined, onSelectAll: () => undefined, onDeselectAll: () => undefined, onDismiss: () => undefined, }; return ( <> <BulkActionBar {...barProps} destructiveAction={{ text: "Delete", onClick: () => requestDelete(4, () => undefined), subActions: [ { text: "Delete and remove data", onClick: () => requestDelete(4, () => undefined, true), }, ], }} /> <BulkActionBar {...barProps} actions={[ { text: "Edit", onClick: () => console.log("Edit"), subActions: [{ text: "Edit metadata", onClick: () => console.log("Edit metadata") }], }, ]} /> {modal} </> );

Usage with a table, toolbar, and actions

A full example: a TableToolbar (with a filter and search) sits above the bar, and the bar sits above the Table. "Tag" and "Export" are ordinary actions (here Export downloads the selected rows as CSV), and "Delete" opens a confirm modal.

Add a leading checkbox column. The header checkbox is tri-state and page-scoped only (empty selects the page, a dash selects the rest of the page, a check clears the page).

Selection persists when you turn a page but clears when you change the filter, search, or page size. In a real, server-side-paginated table the page holds only the loaded rows, while "Select all" is a server-side predicate that covers the whole filtered set.

2 selected
Careers
careers.example.com
Corporate
Community forum
community.example.com
Product
Company blog
blog.example.com
Marketing
Developer docs
docs.example.com
Product
Events
events.example.com
Marketing
1 - 5 of 12 sites
Theme: agentic-ai-2025
const [sort, setSort] = useState<SortField<Site>>({ property: "name", direction: "asc" }); const [category, setCategory] = useState<Category | undefined>(undefined); const [query, setQuery] = useState(""); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(PAGE_SIZE_DEFAULT); const [message, setMessage] = useState<string | null>(null); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const filtered = useMemo( () => allSites .filter((s) => !category || s.category === category) .filter((s) => `${s.name} ${s.url}`.toLowerCase().includes(query.toLowerCase())) .sort((a, b) => compare(a, b, sort)), [category, query, sort] ); // Opens with two rows already selected, so the bar is on screen from the start. const bulk = useBulkActions(filtered.length, (s: Site) => s.id, { initialSelectedIds: [6, 10] }); // Dismissing unmounts the bar, so hand focus back to the page-header checkbox. BaseCheckbox // renders the <input> itself and takes no ref, hence the wrapper. const headerCheckboxRef = React.useRef<HTMLSpanElement>(null); const focusHeaderCheckbox = () => headerCheckboxRef.current?.querySelector("input")?.focus(); const pageItems = filtered.slice((page - 1) * pageSize, page * pageSize); const barProps = bulk.getBarProps(pageItems); const { allSelected: pageAllSelected, someSelected: pageSomeSelected } = bulk.pageState(pageItems); const resetView = () => { bulk.clear(); setPage(1); }; const exportCsv = useCsvExporter<Site>([ { header: "Name", render: (s) => s.name }, { header: "URL", render: (s) => s.url }, { header: "Category", render: (s) => s.category }, ]); const [filterButton, activeFilters] = useSingleFilter<Category>( category, (value) => { setCategory(value); resetView(); }, { label: "Category", name: "category", stringify: (c) => c ?? "", items: categories.map((c) => ({ title: c, value: c })), compareFn: (a, b) => a === b, } ); return ( <div> {message && ( <Message type="positive" dismissible onDismiss={() => setMessage(null)}> {message} </Message> )} <TableToolbar actions={<Button variant="primary">+ Add</Button>} customViews={<Button>Custom views</Button>} filter={filterButton} activeFilters={activeFilters} search={ <InputField aria-label="Search sites" placeholder="Search sites" value={query} onChange={(value) => { setQuery(value); resetView(); }} /> } /> <BulkActionBar {...barProps} actions={[ { text: "Tag", icon: <IconLabel />, // A successful action confirms with a count and clears the selection, which takes the // bar with it. Export doesn't, since it changes nothing. onClick: () => { setMessage(`${barProps.selectedCount} sites tagged.`); bulk.clear(); }, }, { text: "Export", icon: <IconDownload />, onClick: () => { // Client-side: filter the full array with the predicate. Server-side you'd send // `bulk.selection` to the backend instead. exportCsv( filtered.filter((s) => bulk.isSelected(s) === true), { fileName: "sites.csv" } ); setMessage(`${barProps.selectedCount} sites exported to CSV.`); }, }, ]} destructiveAction={{ text: "Delete", onClick: () => setConfirmDeleteOpen(true), }} onDismiss={() => { bulk.clear(); focusHeaderCheckbox(); }} /> <Table items={pageItems} loading={false} sort={sort} setSort={(property, direction) => setSort({ property, direction: property === sort.property ? invertDirection(sort.direction) : direction, }) } rowKey={(site) => site.id} highlightRow={(site) => bulk.isSelected(site) === true} columns={[ { header: { contentNode: ( <span ref={headerCheckboxRef}> <BaseCheckbox aria-label="Select all rows on this page" checked={pageAllSelected} indeterminate={pageSomeSelected} onChange={() => pageAllSelected ? barProps.onDeselectPage() : barProps.onSelectPage() } /> </span> ), }, render: (site) => ( <BaseCheckbox aria-label={`Select ${site.name}`} checked={bulk.isSelected(site) === true} onChange={() => bulk.toggleRow(site)} /> ), options: { width: 48, align: "center" }, }, { header: { property: "name", content: "Name", defaultSortDirection: "asc" }, render: (site) => site.name, options: { isKeyColumn: true }, }, { header: { property: "url", content: "URL", defaultSortDirection: "asc" }, render: (site) => site.url, }, { header: { property: "category", content: "Category", defaultSortDirection: "asc" }, render: (site) => site.category, }, ]} pagination={{ total: filtered.length, page, setPage, // selection persists across pagination pageSize, setPageSize: (size) => { setPageSize(size); resetView(); // changing page size clears the selection }, cancelLabel: "Cancel", confirmLabel: "Confirm", firstLabel: "First", prevLabel: "Previous", nextLabel: "Next", lastLabel: "Last", pagingInfoLabel: (startIdx, endIdx, total) => `${startIdx} - ${endIdx} of ${total} sites`, pageLabel: "Page", pageXofYLabel: (current, total) => `Page ${current} of ${total}`, pageSizeSelectionLabel: (size) => `${size} items`, pageSizeSelectorPrefix: "Show", pageSizeSelectorPostfix: "per page", pageSizeLabel: "Items per page", defaultError: "Invalid page number", wholeNumberError: "Must be a whole number", outOfBoundsError: (total) => `Enter a number between 1 and ${total}`, }} /> <Modal shown={confirmDeleteOpen} headerTitle={`Delete ${barProps.selectedCount} sites?`} onClose={() => setConfirmDeleteOpen(false)} > <Modal.Content> <Paragraph> The {barProps.selectedCount} selected sites will be permanently deleted. This action cannot be undone. </Paragraph> </Modal.Content> <Modal.Footer> <DestructiveActionBar cancel={{ children: "Cancel", onClick: () => setConfirmDeleteOpen(false) }} destructive={{ children: "Delete", onClick: () => { setConfirmDeleteOpen(false); setMessage(`${barProps.selectedCount} sites deleted.`); bulk.clear(); }, }} /> </Modal.Footer> </Modal> </div> );

Usage with a List table

The pattern works the same above a List table. A List table has no column-header checkbox, so page/all selection is driven entirely by the scope menu — the rows still carry their own checkboxes. Switching tab changes the result set, so the selection resets with it, and because there is no header checkbox to return to, dismissing the bar hands focus back to the toolbar.

1 selected
Name
URL
Category
Marketing site
marketing.example.com
Marketing
Support portal
support.example.com
Product
Developer docs
docs.example.com
Product
Company blog
blog.example.com
Marketing
1 - 4 of 12 sites
Theme: agentic-ai-2025
const [page, setPage] = useState(1); const [tab, setTab] = useState(0); const { requestDelete, modal } = useConfirmDelete(); // Opens with one row selected, so the bar is on screen from the start. const bulk = useBulkActions(allSites.length, (s: Site) => s.id, { initialSelectedIds: [1] }); const pageItems = allSites.slice((page - 1) * LIST_PAGE_SIZE, page * LIST_PAGE_SIZE); const total = allSites.length; const barProps = bulk.getBarProps(pageItems); // A list table has no header checkbox, so focus goes back to the toolbar on dismiss. const toolbarRef = React.useRef<HTMLDivElement>(null); const focusToolbar = () => toolbarRef.current?.querySelector("button")?.focus(); const list = ( <div> <div ref={toolbarRef}> <TableToolbar actions={<Button variant="primary">+ Add</Button>} search={<InputField aria-label="Search" placeholder="Search" value="" onChange={noop} />} /> </div> <BulkActionBar {...barProps} actions={[ { text: "Share", icon: <IconLabel />, onClick: () => console.log("Share", barProps.selectedCount), }, ]} destructiveAction={{ text: "Delete", onClick: () => requestDelete(barProps.selectedCount, bulk.clear), }} onDismiss={() => { bulk.clear(); focusToolbar(); }} /> <ListTable items={pageItems} loading={false} columns={[ { header: { content: "" }, render: (site) => ( <BaseCheckbox aria-label={`Select ${site.name}`} checked={bulk.isSelected(site) === true} onChange={() => bulk.toggleRow(site)} /> ), options: { width: 48, align: "center" }, }, { header: { content: "Name" }, render: (site) => site.name, options: { isKeyColumn: true }, }, { header: { content: "URL" }, render: (site) => site.url, }, { header: { content: "Category" }, render: (site) => site.category, }, ]} pagination={{ total, page, setPage, // selection persists across pagination pageSize: LIST_PAGE_SIZE, setPageSize: null, cancelLabel: "Cancel", confirmLabel: "Confirm", firstLabel: "First", prevLabel: "Previous", nextLabel: "Next", lastLabel: "Last", pagingInfoLabel: (startIdx, endIdx, count) => `${startIdx} - ${endIdx} of ${count} sites`, pageLabel: "Page", pageXofYLabel: (current, count) => `Page ${current} of ${count}`, pageSizeSelectionLabel: (size) => `${size} items`, pageSizeSelectorPrefix: "Show", pageSizeSelectorPostfix: "per page", pageSizeLabel: "Items per page", defaultError: "Invalid page number", wholeNumberError: "Must be a whole number", outOfBoundsError: (count) => `Enter a number between 1 and ${count}`, }} /> {modal} </div> ); return ( <Tabs selectedTab={tab} // Switching tab changes the result set, so the selection resets with it. onChange={(next: number) => { setTab(next); bulk.clear(); setPage(1); }} tabs={[ { header: "Active", content: list }, { header: "Archived", content: <Paragraph>No archived sites.</Paragraph> }, ]} /> );

View selected only

The optional viewSelectedOnly toggle narrows the table to the selected rows so the user can check the selection before acting on it. It is worth adding when the selection can reach beyond the current page. Filtering the rows is your job, not the bar's.

The bar is the toggle's only home, so clearing the selection takes the toggle with it. Give the table a noDataState with a way back rather than leaving a blank table behind.

2 selected
Select
Site
careers.example.com
community.example.com
Theme: agentic-ai-2025
const [viewSelected, setViewSelected] = useState(true); const bulk = useBulkActions(allSites.length, (s: Site) => s.id, { initialSelectedIds: [6, 10] }); const visible = viewSelected ? allSites.filter((s) => bulk.isSelected(s) === true) : allSites; const barProps = bulk.getBarProps(visible); return ( <div> <BulkActionBar {...barProps} actions={[{ text: "Export", onClick: noop }]} viewSelectedOnly={{ value: viewSelected, onChange: setViewSelected }} onDismiss={bulk.clear} /> <Table items={visible} loading={false} sort={null} setSort={noop} rowKey={(site) => site.id} highlightRow={(site) => bulk.isSelected(site) === true} // With the filter on and nothing selected the bar unmounts, taking the toggle with it. Show // the table's empty state with a way back rather than a blank table. noDataState={ <EmptyState type="reassure" description="No rows match the current selection." button={{ text: "Show all rows", onClick: () => setViewSelected(false) }} /> } columns={[ { header: srOnlyHeader("Select"), render: (site) => ( <BaseCheckbox aria-label={`Select ${site.name}`} checked={bulk.isSelected(site) === true} onChange={() => bulk.toggleRow(site)} /> ), options: { width: 48, align: "center" }, }, { header: { content: "Site" }, render: (site) => site.url }, ]} /> </div> );

Usage inside a picker (selection only)

The bar can also act as a pure selection control — without actions — to power multi-select inside a picker. Here it sits at the top of a Site picker-style dropdown: the tri-state column-header checkbox selects the current page, and once a row is selected the bar's scope menu extends the selection to the whole set ("Select all items"). The picker's own footer confirms it — "Add 4 sites". The SitePicker component itself is single-select; this is a composition built from BaseTablePicker + Table + BulkActionBar.

Theme: agentic-ai-2025
const [sort, setSort] = useState<SortField<Site>>({ property: "name", direction: "asc" }); // Seed the picker with two rows already selected, as an "edit existing selection" dialog would. const bulk = useBulkActions(allSites.length, (s: Site) => s.id, { initialSelectedIds: [1, 2] }); const total = allSites.length; const pageItems = allSites; // the picker shows the whole set on one page const barProps = bulk.getBarProps(pageItems); const { allSelected: pageAllSelected, someSelected: pageSomeSelected } = bulk.pageState(pageItems); return ( <BaseTablePicker itemsCount={allSites.length} totalItems={total} selectedItem={barProps.selectedCount > 0 ? allSites[0] : undefined} selectedItemStringify={() => `${barProps.selectedCount} sites selected`} buttonIcon={<IconSite />} texts={{ buttonContentNoItemSelected: "Select sites", showingXOfYItems: (showing, t) => `Showing ${showing} of ${t} sites`, }} contentItems={(_firstFocusableRef, close, tableClassName) => ( <> {/* Selection only — no actions. The picker's own footer confirms the selection. */} <BulkActionBar {...barProps} onDismiss={bulk.clear} /> <Table items={allSites} loading={false} sort={sort} setSort={(property, direction) => setSort({ property, direction: property === sort.property ? invertDirection(sort.direction) : direction, }) } rowKey={(site) => site.id} highlightRow={(site) => bulk.isSelected(site) === true} className={tableClassName} columns={[ { header: { contentNode: ( <BaseCheckbox aria-label="Select all rows on this page" checked={pageAllSelected} indeterminate={pageSomeSelected} onChange={() => pageAllSelected ? barProps.onDeselectPage() : barProps.onSelectPage() } /> ), }, render: (site) => ( <BaseCheckbox aria-label={`Select ${site.name}`} checked={bulk.isSelected(site) === true} onChange={() => bulk.toggleRow(site)} /> ), options: { width: 48, align: "center" }, }, { header: { property: "url", content: "Site", defaultSortDirection: "asc" }, render: (site) => site.url, options: { isKeyColumn: true }, }, ]} /> <div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem", padding: "0.75rem", }} > <Button onClick={close}>Cancel</Button> <Button variant="primary" disabled={barProps.selectedCount === 0} onClick={close}> Add {barProps.selectedCount} sites </Button> </div> </> )} /> );

Properties

4 selected
Theme: agentic-ai-2025
PropertyDescriptionDefinedValue
selectedCountRequired
numberNumber of currently selected items across all pages.
totalCountRequired
numberTotal number of items in the current filtered set (the "(T)" in "Select all items (T)").
selectedOnPageCountRequired
numberNumber of selected items on the current page, driving the page-scoped commands.
pageItemCountRequired
numberTotal number of items on the current page.
onSelectPageRequired
functionSelects every item on the current page.
onDeselectPageRequired
functionDeselects every item on the current page.
onSelectAllRequired
functionSelects the entire filtered set (a server-side predicate, not an enumerated list).
onDeselectAllRequired
functionDeselects the entire selection.
onDismissRequired
functionClears the selection and collapses the bar (the `×` button, and Escape with the menu closed). Move focus on from here — the bar unmounts.
actionsOptional
object[]Actions for the selection, in the order given. Keep to three or fewer or the bar wraps.
destructiveActionOptional
objectThe destructive action (e.g. Delete), rendered apart in its own divider group.
viewSelectedOnlyOptional
objectRenders a "View selected only" toggle before the dismiss button. Narrowing the rows is the consumer's job; with it on and nothing selected, show the table's empty state, not a blank table.
data-observe-keyOptional
stringUnique string, used by external script e.g. for event tracking
classNameOptional
stringCustom className that's applied to the outermost element (only intended for special cases)
styleOptional
objectStyle object to apply custom inline styles (only intended for special cases)

Guidelines

Best practices

When to use it

  • Use when a workflow acts on more than one row (add, edit, tag, export, delete).
  • One bar per table. If a page holds two tables, each keeps its own selection and its own bar.

Placement

  • Order the bar left to right: scope (count trigger) > "Select all" shortcut > actions > destructive > dismiss.
  • Keep the bar in the table's own row, between the toolbar and the column headers. It pushes the table down, and never floats over rows or covers the filter and search the user just used.

Actions

  • Give each entry in the actions slot an icon and a label; the bar renders them as default-variant buttons. Keep it to three or fewer so the bar does not wrap; move the rest into an Action menu. Disable an action that cannot run on the current selection.
  • Keep the destructive action separated, and let a delete open a confirm modal. When an action has more than one way to run, give it subActions rather than adding a second button — the main button stays the common choice.
  • Don't put "Add" or any other create action in the bar. It acts on a selection, so a control that makes something new belongs in the toolbar or page header.
  • Show progress on the action that is running — set its loading flag — and keep the bar in place until it finishes. A bulk job on 200 rows is not instant.

Scope, counts and feedback

  • Scope commands pair generic, entity-agnostic copy with a live count ("Select all items (33)", "Select current page (10)") — so they work across sites / users / keywords without per-entity translation keys, while still showing each command's scope.
  • Add the viewSelectedOnly toggle whenever a selection can reach beyond the current page, so users can see what they are about to act on before they act.
  • Confirm the outcome with a count ("4 sites tagged"), and clear the selection once the action succeeds.
  • Keep the selection if the action fails, so the user can retry without selecting everything again.
  • Never let the count disagree with what the action will affect. If the two can drift apart, say which one the user is looking at.

Do not use when

  • Every action is single-row. Use inline row actions instead.
  • The action always applies to the whole dataset rather than a selection, such as an "Export all" or "Recrawl" that ignores which rows are checked. Put it in the Table toolbar.
  • The action creates something instead of acting on existing rows. "Add", "Invite" and "New" belong in the toolbar or page header.
  • The table holds a handful of rows that will not grow, and acting on them one at a time never feels repetitive. The bar costs a row of vertical space and a selection model, so earn it.
  • The flow needs the selection to survive a change of result set. Selection clears on filter, search, page size and tab changes, so anything that carries rows across those needs a different pattern.
  • There is exactly one action and it is destructive. A bar whose only button is Delete reads as a delete mode; consider whether the pattern the user needs is really multi-select at all.

Accessibility

Structure and focus order

  • The bar is a role="group" labelled "Bulk actions". Focus order runs scope trigger > "Select all" > actions > destructive > dismiss, with the viewSelectedOnly toggle just before dismiss when it is there.
  • The dismiss control is an icon-only, default (bordered) button labelled "Close bulk actions". Move focus somewhere sensible when it is used — the page-header checkbox, or the table toolbar in a List table — since the bar unmounts.

Keyboard

  • The scope trigger behaves as a single-select listbox, not a command menu: it exposes aria-haspopup="listbox" and aria-expanded, announces as "Selected items: 4 selected", and the menu is keyboard navigable (arrow keys, Enter/Space). Its options use the same active-option styling as Select — a visible border, not a background tint alone.
  • Esc closes the scope menu; pressing it again clears the selection and hides the bar, the same as the dismiss button.

Labels

  • The "Select all" shortcut is a secondary <button> with its own keyboard and screen-reader support, and hides once everything is selected.
  • The chevron on an action with subActions has no visible text, so the bar labels it "More options" for you.

Announcements and state

  • The selected count is announced via an aria-live region as the selection changes, without moving focus.
  • The consumer's header checkbox must be tri-state: set aria-checked="mixed" (via the indeterminate prop of Checkbox) when only some rows on the page are selected.

Writing

  • Scope labels use generic, entity-agnostic copy plus a count in parentheses: "Select all items (33)" / "Select current page (10)". The count is what the command would affect, not the size of the dataset — with 21 selected, the menu reads "Deselect all items (21)".
  • Use verbs for action labels (Tag, Export, Delete).
  • Name a sub-action for what it does beyond the main one — "Delete and remove data", not "Quick delete".
  • Keep the count out of action labels. Write "Delete", not "Delete 4". The count already lives in the scope trigger, and two counts that can disagree is worse than one.
  • Name the count and the entity in confirmations and results: "Delete 4 sites?" in the modal, "4 sites deleted" in the toast. Not "Are you sure?" and not "Success".